Skip to content

feat(plugin-form): honour a section visibleWhen on the tabbed layout arm - #6791

Open
os-sales wants to merge 1 commit into
mainfrom
claude/issue-6237-grouping-predicate-contract
Open

feat(plugin-form): honour a section visibleWhen on the tabbed layout arm#6791
os-sales wants to merge 1 commit into
mainfrom
claude/issue-6237-grouping-predicate-contract

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Part of #6237 — the formType: 'tabbed' arm of the one grouping contract ruled 2026-08-29 (option A). Deliberately not a closing keyword: two things named at the bottom stay open, and one of them wants a ruling.

Base: 107babef6. Final commit: 02641f20f (every reading below taken there).

What was broken, re-derived on this base

ObjectForm rebuilds each section key by key, per layout route. The split, drawer and modal maps each copy visibleWhen; the flat arm carries it on the section-divider pseudo-field. The tabbed map copied nothing, so an authored FormSection.visibleWhen never reached a renderer on that route at all.

The useful measurement is that this was not a missing mechanism. TabbedForm already synthesises the renderer's fieldTabs — the very machinery the modal + contentLayout: 'tabbed' arm runs on, where FormFieldTab.visibleWhen has been evaluated since PR #6619. The predicate was only being dropped on the way there, at three points, all three of which now carry it:

  1. ObjectForm's tabbed section map.
  2. FormSectionConfig — which declared no such key.
  3. TabbedForm's fieldTabs synthesis.

So this arm reaches the existing evaluator by the existing route. The renderer is unchanged; nothing in packages/components or packages/types was touched.

The three ruled semantics — two settled by existing evidence, one decided here

The ruling names three semantics the design must answer. Stating plainly which were decided by whom:

1. Hidden tab and required fields — SETTLED, inherited. Ruled 2026-08-27 (visibility gates drawing only; hidden-group values still submit; hidden-group fields skip client-side validation, with the server as the loud floor) and implemented in the renderer for fieldTabs by PR #6619. Because a hidden tab's panel is simply not drawn, its fields unmount — the same mechanism a field's own false predicate uses — so react-hook-form keeps the values and skips the unmounted controls. This arm inherits that; it is not re-implemented beside it. Pinned in tabbedFormSectionPredicate-6237.test.tsx, including the control that a required field on a merely inactive (not predicate-hidden) tab still blocks — without which "does not block" would be satisfied by a form that stopped validating altogether.

2. The active tab hides itself — SETTLED, inherited. activeFieldTab derives over the visible tabs: the user's pick if still visible, else the declared default, else the first visible tab. The pick is kept rather than cleared, so the chosen tab becomes active again when its predicate re-admits it. Never an empty panel. Two pins, including one where the declared default is the hidden tab.

3. Collapse below two tabs — SETTLED for the case everyone means; DECIDED HERE for the other. This name covers two situations:

  • (3a) A predicate hides one of two tabs. Answered upstream and inherited: engagement is judged on the DECLARED tabs, so a predicate only filters what is drawn and cannot collapse the strip mid-interaction.
  • (3b) The form declares fewer than two tabs. The renderer's tab arm needs more than one usable tab to engage, so a single-section tabbed form is already the untabbed layout — there was no tab to carry a predicate, and the key stayed inert in that one shape.

(3b) is the only thing this PR decided. Left alone it would have made things worse, and that is why it is in scope rather than deferred: ObjectForm stops reporting the tabbed gap now that the arm supports the key, so the single-section case would have gone from loudly-inert to silently inert — this card's own defect class, reintroduced by its own fix.

The defined degradation is the untabbed layout's own mechanism (#6236): a section-divider row claiming its members by name, so the verdict gates the whole group through the identical unmount path and the ruled semantics stay identical across both shapes. It is chrome-less on purpose — no label, no description, and SectionDivider renders nothing without them — because a single-section tabbed form draws no tab strip today, so its heading is already absent; synthesising a visible one would change the layout of every such form under the banner of a predicate fix. It is emitted only for a section that actually authored a predicate, so existing forms are unchanged. Three pins, including the no-predicate row that holds that last property.

The shared-type trap, and why the fix is an omission

WizardForm borrowed FormSectionConfig as its step type. Declaring the predicate on the shared type would have made it writable on a wizard step while the wizard renderer ignores it — exactly the declared-but-unenforced shape this card family exists to close.

WizardStepConfig is FormSectionConfig with visibleWhen omitted, so the key is writable precisely where it is honoured, and a wizard step literal carrying it is a compile error. That is pinned as a type assertion (@ts-expect-error under tsconfig.test.json), which fails the build if the key ever becomes writable there — prose cannot hold that boundary.

This changes nothing that used to work: FormSectionConfig did not declare visibleWhen before this card either, so the key was already a type error on a wizard step. Only the tabbed arm widened. The widened surface is the plugin-form barrel's exported section type, not packages/types.

The runtime diagnostic from PR #6733 is narrowed to wizard, the one arm still inert. A diagnostic that keeps warning about a feature that started working is a false alarm, and false alarms are how a real one stops being read. Its remedy sentence now names tabbed as a working arm, with a pin on the sentence itself.

Verification, at 02641f20f

  • Suites: pnpm exec vitest run packages/plugin-form/ packages/components/src/renderers/form/Test Files 134 passed (134), Tests 1174 passed (1174). Run at the final commit, from the repo root per AGENTS.md.
  • Type-check: @object-ui/plugin-form type-check exit 0 (tsc --noEmit && tsc -p tsconfig.test.json) after building the dependency closure. Positive control that it really reads my files: an earlier run went red naming the two stale 'tabbed' arguments in the diagnostic pin.
  • Ablation (fix committed first; trap restores with absolute paths off git rev-parse --show-toplevel). Neutralised only the fieldTabs predicate copy, leaving the ObjectForm map and the degradation gate intact. Mutation proven on disk by anchored count (1 to 0) plus blob change 3628c3cf to 393a4be6, not by an editor exit code. Predicted 6 red and observed exactly those 6, row for row: the Authored FormSection.visibleWhen is dropped by all four plugin-form layouts — declared, bridged, then never evaluated on the object-view chain #6111 matrix's tabbed DENIED row, both semantic-1 rows, both semantic-2 rows, and the semantic-3a row. The controls held: semantic 1's visible-tab row, semantic 3a's ALLOWED row, all three semantic-3b rows (they run on the degradation mechanism, which the mutation deliberately left standing — that is what proves the two paths are genuinely separate rather than one edit's collateral), both type rows, and the whole diagnostic pin. Restore proven both ways: blob hash equal to the HEAD blob AND an empty git diff HEAD. A first attempt aborted on its own guard, which is recorded because the guard was right to fire: g.visibleWhen is copied at two sites, so a loose count read a correct, precise mutation as a failure.
  • Cross-package type probe, both directions, read from the rebuilt dist/index.d.ts (not source, not cache): the wizard step line drew exactly TS2353 ... 'visibleWhen' does not exist in type 'WizardStepConfig' while the tabbed section line on the same compiled declaration compiled clean. A bare-error control in the same query proved the probe file is genuinely compiled.
  • Gates, each quoted from its own verdict line: check:control-bytes "OK (scanned 5636 tracked text file(s))"; check:self-import "No package names itself inside its own src/"; check:phantom-deps "Every in-scope import is declared by the package that publishes it"; check:vi-mock-specifiers OK; check:spec-symbols OK; check:i18n-keys "Every in-scope call-site key resolves against the en pack"; check:i18n-drift "No en value changed in this range"; check:changeset-presence "7 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"; check:changeset-no-major "No changeset declares a major bump". No author-facing string was added, so no i18n key is owed — the diagnostic is a developer console message, English-only per Commandment #-1.
  • check:readme-exports: measured for this package after building it — plugin-form's rows are clean, and WizardStepConfig resolves out of the built dist/index.d.ts. The gate's overall exit 1 is entirely the "type entry not on disk, run build first" class across other unbuilt packages: tree state, not a diff verdict, and this diff touches zero README lines.
  • Lint, with a baseline rather than a bare count: targeted eslint --no-inline-config on the 8 changed files, 0 errors, and 0 net new warnings — checked against the base versions of the same files, not just counted. That comparison earned its keep: it caught my tabbed-map line adding a 57th no-explicit-any to ObjectForm.tsx. ObjectFormSection.visibleWhen is in fact declared in packages/types, so the as any the three sibling maps use is unnecessary; dropping it restored the file to its baseline 60 and means a rename of that key is now caught by the compiler here instead of silently copying undefined — the exact silent-drop failure this line exists to fix. Narrowing is sound: the config does no type-aware linting, so this diff cannot move an untouched file's verdict; the full sweep is CI's run.

What stays open, precisely

  1. The wizard step arm. A different contract, not a port: step predicates would be step-boundary reactive against the ruled live-record reactivity, and they need navigation, indicator, isLastStep, final-gate and re-selection semantics that none of this machinery supplies. Measured business pull remains zero. Unruled, so not built; the type omission and the runtime diagnostic keep it honest meanwhile.
  2. Semantic 1's cross-repo half, which wants a ruling. The ruling asks whether the server-side required check reads the same predicate. Measured in ../objectstack: it does not, and structurally cannot — packages/objectql's rule validator has zero references to FormSection or sections; it validates against the object's field definitions, and never sees the form view. Server-side visibleWhen exists only for choice options. So the ruled "server is the loud floor" is internally consistent, and the residual authoring hazard is a genuinely object-required field placed inside a predicate-hidden section, which will be rejected by the server. Teaching the server to read a form predicate would be a published validation contract change in @objectstack/objectql — outside this fence and outside any ruling, so it is reported rather than attempted.

Generated by Claude Code


Generated by Claude Code

The tabbed arm of the one grouping contract ruled 2026-08-29 (objectui#6237,
option A). An authored `FormSection.visibleWhen` was dropped on the `tabbed`
route while split/drawer/modal and the flat layout honoured it.

`TabbedForm` already synthesises the renderer's `fieldTabs` — the same
machinery the modal tabbed arm runs on — so the predicate was only being
dropped on the way there, at three points that now all carry it: ObjectForm's
tabbed section map, `FormSectionConfig` (which declared no such key), and
TabbedForm's `fieldTabs` synthesis.

The three ruled semantics are therefore inherited, not re-implemented: hidden
tabs' values still submit, their fields skip client-side validation, hiding the
active tab re-selects deterministically, and engagement stays structural on the
declared tabs so a predicate cannot collapse the strip mid-interaction.

Two boundaries are deliberate. A single-section tabbed form never engages the
tab arm, so it degrades to the untabbed layout's own predicate mechanism (a
chrome-less section-divider claiming its members); forms without a predicate are
unchanged. Wizard steps still take no predicate and now say so in the type —
`WizardStepConfig` omits the key, since a step predicate is a different
contract — with the runtime diagnostic narrowed to that one remaining arm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 45 chunks) 3174.1 KB 3222.7 KB
Main entry chunk (gzip) 148.2 KB 350 KB
Entry file index-_kddhzgN.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 511.75KB 116.33KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 240.93KB 60.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.46KB 62.46KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.32KB 32.69KB
plugin-gantt (index.js) 165.23KB 40.37KB
plugin-grid (index.js) 201.57KB 54.55KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 28.95KB 8.33KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 76.75KB 25.49KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.40KB 1.20KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review August 29, 2026 15:00
@os-sales
os-sales added this pull request to the merge queue Aug 29, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 29, 2026
@os-sales
os-sales added this pull request to the merge queue Aug 29, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 29, 2026
@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Third CI_TIMEOUT, and the measurement that explains all three

Enqueued 17:01Z, ejected 18:02Z — ~61 minutes, with 22 of 24 checks never having started. Zero red. The PR is clean and its own head has been green at 30/30 throughout.

The three ejections now give the mechanism, and it changes what to do about it:

enqueued ejected elapsed progress when ejected
#6791 (1st) 15:00 16:01 ~61 min 20/24 green
#6792 16:03 17:01 ~58 min 17/24 green
#6791 (2nd) 17:01 18:02 ~61 min 0/24 started

The wait ceiling is measured from ENQUEUE, not from when the checks start running. All three land within a three-minute band of ~60 minutes regardless of how much work actually got done — including this one, where nothing ran at all.

Enqueueing into a saturated runner pool is guaranteed waste, not a gamble. The entry burns its entire ceiling waiting for runners that are busy elsewhere.

What the pool was doing, measured

At 18:02, occupancy from this seat's own open PRs:

PR jobs queued or running
#6814 27
#6815 27
#6819 27
#6811 14
#6808 3
#6807 1
total 99

The five verified-green PRs (#6791, #6792, #6796, #6800, #6801) contribute zero — their CI is complete, so holding them as drafts costs no runners at all. The 99 are newer PRs from two dispatch rounds whose CI still has to run.

Not re-queueing until the pool has room — and this time it is a measured threshold, not a guess

⛔ A fourth immediate re-queue would burn another hour for the same reason as the third. Instead a probe is watching this seat's own occupancy, and this PR goes back in as soon as it drops below 20 — one entry, alone.

⚠️ Correcting my own earlier escalation trigger. On #6792 I wrote that a timeout on a single-entry queue would point at the merge queue's own configuration and belonged to #6082's escalation. That was wrong and I am withdrawing it: this ejection happened on a single-entry queue, but the pool was not free — 99 of this seat's own jobs were ahead of it. A single-entry queue is not a free pool. The corrected trigger: only a timeout with this seat's own CI at zero occupancy would implicate the queue's merge_group configuration.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queue Aug 29, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants